Évaluation de BiomedParse sur données DICOM pulmonaires

Objectif :

Ce notebook permet d’évaluer les performances du modèle BiomedParse appliqué à des images scanner (CT thoracique) au format DICOM. Il utilise des fichiers de segmentation DICOM-SEG comme vérité terrain pour mesurer la qualité des prédictions générées à partir de prompts textuels (ex: "tumor").

Étapes principales :

  1. Chargement et initialisation du modèle BiomedParse avec configuration et poids adaptés.
  2. Inférence sur les images DICOM via prompts et génération de masques de prédiction.
  3. Comparaison automatique des masques prédits avec les masques de segmentation GT (Dice Score par image et par patient).
  4. Filtrage qualitatif des masques via auto-Dice (conservation des plus cohérents).
  5. Extraction RECIST : mesure de la plus grande lésion (en mm) dans chaque volume, et classification "Mesurable / Non mesurable".
  6. Analyse globale : statistiques, écarts de mesure, précision des prédictions RECIST, et visualisations finales.

Données attendues :

Le notebook suppose que les données sont disponibles dans le répertoire ./test/dcm/ avec la structure suivante :

./test/dcm/
  ├── <patient_id>/
      ├── 0/   ← fichiers DICOM image (1 par coupe)
      └── 1/
          └── 1-1.dcm  ← fichier DICOM SEG (segmentations vérité terrain)

Seules les coupes DICOM segmentées dans le fichier SEG sont attendues dans 0/.
On peut utiliser pour ça supprNonSeg.py.

Prompt utilisé :

Le prompt textuel pour guider l’inférence est défini dans :

text_prompt = ["tumor"]

À savoir avant d’exécuter :

  • Les chemins configs/ et pretrained/ doivent déjà être présents dans le dépôt cloné (ceci inclut le modèle modifié).
  • Le notebook désactive volontairement la vérification SSL pour permettre les appels à HuggingFace dans un environnement interne.
  • Le modèle peut être exécuté sur CPU ou GPU. L’usage de CUDA est automatique si disponible.
  • La sélection des meilleurs masques s’appuie sur une double itération de tri par Dice auto-comparatif.

Résultats disponibles :

  • Dice moyen par patient (masques bruts et filtrés)
  • Pourcentage de patients avec lésion RECIST mesurable
  • Écart de mesure (en mm) entre GT et prédiction
  • Précision du modèle à prédire correctement le statut RECIST
  • Visualisations : Boxplots, overlays masques vs GT

Base de données utilisée : cancerimagingarchive.net/collection/nsclc-radiomics/


Pour toute modification du modèle, du prompt ou du jeu de données, adapter les chemins et fonctions au besoin.

In [1]:
from PIL import Image
import pydicom
import numpy as np
import cv2
import torch
import json
import argparse
import torch
import glob
from math import dist
from scipy.spatial.distance import pdist, squareform
import matplotlib.pyplot as plt
from modeling.BaseModel import BaseModel
from modeling import build_model
from utilities.distributed import init_distributed
from utilities.arguments import load_opt_from_config_files
from utilities.constants import BIOMED_CLASSES

from inference_utils.processing_utils import read_dicom
from inference_utils.processing_utils import read_png
from inference_utils.inference import interactive_infer_image
from inference_utils.inference import interactive_infer_image_all
from inference_utils.output_processing import check_mask_stats


import os
os.environ['HF_HUB_DISABLE_SSL_VERIFICATION'] = '1'

import urllib3
urllib3.disable_warnings()

import ssl
import requests

# Pour forcer le trust du certificat (déconseillé en prod)
ssl._create_default_https_context = ssl._create_unverified_context

# Vérifie manuellement si HuggingFace répond :
requests.get("https://huggingface.co", verify=False)


# Build model config
def parse_option():
    parser = argparse.ArgumentParser('SEEM Demo', add_help=False)
    parser.add_argument('--conf_files', default="configs/biomedparse_inference.yaml", metavar="FILE", help='path to config file', )
    parser.add_argument('--model_path', default="pretrained/biomedparse_v1.pt", metavar="FILE", help='path to model file')
    cfg = parser.parse_args()
    return cfg

# Charger les options depuis le fichier de configuration
opt = load_opt_from_config_files(["configs/biomedparse_inference.yaml"])

# Initialiser la distribution
opt = init_distributed(opt)

# Chemin vers les poids pré-entraînés
pretrained_pth = 'pretrained/biomedparse_v1.pt'

# Vérifier si CUDA est disponible et définir le périphérique
device = 'cuda' if torch.cuda.is_available() else 'cpu'

# # Créer le modèle avec les options et la fonction de construction (en utilisant le périphérique approprié)
build = build_model(opt, device=device)
model = BaseModel(opt, build)

# # Charger les poids pré-entraînés
model = model.from_pretrained(pretrained_pth).eval()

# # Déplacer le modèle vers le périphérique approprié (CPU ou GPU)
model.to(device)

# # Effectuer la prédiction sans calcul du gradient
with torch.no_grad():
    model.model.sem_seg_head.predictor.lang_encoder.get_text_embeddings(BIOMED_CLASSES + ["background"], is_eval=True)
Deformable Transformer Encoder is not available.
➡️  Type de tokenizer : clip
➡️  Chemin/tokenizer utilisé : ./local-tokenizer/clip-vit-base-patch32
➡️  Contenu complet config_encoder : {'ARCH': 'vlpencoder', 'NAME': 'transformer', 'TOKENIZER': 'clip', 'PRETRAINED_TOKENIZER': './local-tokenizer/clip-vit-base-patch32', 'CONTEXT_LENGTH': 77, 'WIDTH': 512, 'HEADS': 8, 'LAYERS': 12, 'AUTOGRESSIVE': True}
✅ Tokenizer FINAL utilisé: ./local-tokenizer/clip-vit-base-patch32
In [2]:
def plot_segmentation_masks(original_image, segmentation_masks, texts):
    ''' Plot a list of segmentation mask over an image.
    '''
    original_image = original_image[:, :, :3]
    fig, ax = plt.subplots(1, len(segmentation_masks) + 1, figsize=(10, 5))
    ax[0].imshow(original_image, cmap='gray')
    ax[0].set_title('Original Image')
    # grid off
    for a in ax:
        a.axis('off')

    for i, mask in enumerate(segmentation_masks):
        
        ax[i+1].set_title(texts[i])
        mask_temp = original_image.copy()
        mask_temp[mask > 0.5] = [255, 0, 0]
        mask_temp[mask <= 0.5] = [0, 0, 0, ]
        ax[i+1].imshow(mask_temp, alpha=0.9)
        ax[i+1].imshow(original_image, cmap='gray', alpha=0.5)
    plt.show()

def inference_dicom(file_path, text_prompts, is_CT, site=None):
    image = read_dicom(file_path, is_CT, site=site)
    pred_mask = interactive_infer_image(model, Image.fromarray(image), text_prompts)
    # pred_mask = interactive_infer_image_all(model, Image.fromarray(image), 'CT-Chest')
    return image, pred_mask

def inference_png(file_path, text_prompts, is_CT, site=None):
    image = read_png(file_path, is_CT, site=site)
    pred_mask = interactive_infer_image(model, Image.fromarray(image), text_prompts)
    return image, pred_mask

Lecture et traitement des DICOMS avec pydicom

In [ ]:
import pydicom
import numpy as np
import cv2

def diceScore(mask1,mask2):
    mask1 = (mask1 > 0.5).astype(np.uint8)
    mask2 = (mask2 > 0.5).astype(np.uint8)

    intersection = np.sum(mask1 * mask2)
    tot = np.sum(mask1) + np.sum(mask2)

    res = 0

    if tot != 0 :
        res = ( 2.0 * intersection ) / tot

    return res

def get_target_segment_number(seg, keyword="neoplasm"):
    for s in seg.SegmentSequence:
        if keyword.lower() in s.SegmentLabel.lower():
            return s.SegmentNumber
    raise ValueError(f"Aucun segment contenant '{keyword}' trouvé dans le fichier de segmentation.")

text_prompt = ['tumor']

def showInference(patient):

    images_dir = f"./test/dcm/{patient}/0/"       # Dossier contenant les fichiers DICOM image
    seg_path = f"./test/dcm/{patient}/1/1-1.dcm"  # Fichier de segmentation DICOM SEG

    dicom_files = sorted(os.listdir(images_dir))
    image_slices = [pydicom.dcmread(os.path.join(images_dir, f)) for f in dicom_files]

    seg = pydicom.dcmread(seg_path)
    n_frames = int(seg.NumberOfFrames)
    # print(n_frames)


    ref_uids = [ref.ReferencedSOPInstanceUID for ref in seg.ReferencedSeriesSequence[0].ReferencedInstanceSequence]

    # print(seg.SegmentSequence)
    # print(seg.PerFrameFunctionalGroupsSequence)

    segmentsLabels = {
        s.SegmentNumber: s.SegmentLabel
        for s in seg.SegmentSequence
    }

    # for num, label in segmentsLabels.items():
    #     print(f"{num} → {label}")

    # target_segment_number = 2  # on segmente la tumeur (neoplasm)

    target_segment_number = get_target_segment_number(seg)

    masks = []
    uids = []

    for i, f in enumerate(seg.PerFrameFunctionalGroupsSequence):
        if int(f.SegmentIdentificationSequence[0].ReferencedSegmentNumber) == target_segment_number:
            masks.append(seg.pixel_array[i])
            uids.append(f.DerivationImageSequence[0].SourceImageSequence[0].ReferencedSOPInstanceUID)

    for dicom_file, img_dcm in zip(sorted(os.listdir(images_dir)), image_slices):
        sop_uid = img_dcm.SOPInstanceUID
        img_path = os.path.join(images_dir, dicom_file)

        image, pred_mask = inference_dicom(img_path, text_prompt, is_CT=True, site='lung')
        pred_mask = np.squeeze(pred_mask)
        pv=0

        if sop_uid in uids:
            seg_idx = uids.index(sop_uid)
            seg_mask = masks[seg_idx]
        else:
            seg_mask = np.zeros_like(img_dcm.pixel_array)

        img_array = img_dcm.pixel_array

        target_size = img_array.shape[::-1]  
        pred_mask_resized = cv2.resize(pred_mask, target_size, interpolation=cv2.INTER_NEAREST)

        dice = diceScore(pred_mask_resized, seg_mask)

        print(sorted(os.listdir(images_dir)).index(dicom_file))

        plt.figure(figsize=(6, 6))
        plt.imshow(img_array, cmap='gray')
        plt.imshow(seg_mask, alpha=0.4, cmap='Blues')
        plt.imshow(pred_mask_resized, alpha=0.4, cmap='Reds')

        plt.axis('off')
        plt.title("SEG (Bleu) vs Prédiction (Rouge)")

        plt.figtext(0.5, -0.01, f"Dice Score: {dice:.4f} ", wrap=True, ha='center', fontsize=10)

        plt.tight_layout()
        plt.show()


showInference(15)
0
No description has been provided for this image
1
No description has been provided for this image
2
No description has been provided for this image
3
No description has been provided for this image
4
No description has been provided for this image
5
No description has been provided for this image
6
No description has been provided for this image
7
No description has been provided for this image
8
No description has been provided for this image
9
No description has been provided for this image
10
No description has been provided for this image
11
No description has been provided for this image
12
No description has been provided for this image
13
No description has been provided for this image
14
No description has been provided for this image
15
No description has been provided for this image
16
No description has been provided for this image
17
No description has been provided for this image
18
No description has been provided for this image
19
No description has been provided for this image
20
No description has been provided for this image
21
No description has been provided for this image

DICE SCORE¶

Formule du Dice score : $$ \text{Dice} = \frac{2 \cdot |A \cap B|}{|A| + |B|} $$ où ${|A|}$ représente le nombre de pixel de A.

Les codes suivants sont à adapter à la bdd considérer. Il faut regarder quel étiquette la bdd donne aux tumeur pour pouvoir les cibler. Dans celle utilisé dans ce notebook l'étiquette est 'neoplasm'.

In [12]:
import os
import pydicom
import numpy as np
import matplotlib.pyplot as plt
import cv2

all_patient_scores = []

base_path = "./test/dcm/"
patients = sorted([p for p in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, p))])

for patient_id in patients:
    images_dir = os.path.join(base_path, patient_id, "0")
    seg_path = os.path.join(base_path, patient_id, "1", "1-1.dcm")

    # Skip patient if segmentation file is missing
    if not os.path.exists(seg_path):
        print(f"[WARN] No segmentation file for patient {patient_id}")
        continue

    # Lire les images DICOM
    dicom_files = sorted(os.listdir(images_dir))
    image_slices = [pydicom.dcmread(os.path.join(images_dir, f)) for f in dicom_files]

    # Lire segmentation DICOM SEG
    seg = pydicom.dcmread(seg_path)
    try :
        target_segment_number = get_target_segment_number(seg)
    except Exception as e:
        print(f"Erreur avec le patient {patient_id}: {e}")
        continue

    # Extraire masques SEG
    uids = []
    masks = []
    for i, f in enumerate(seg.PerFrameFunctionalGroupsSequence):
        if int(f.SegmentIdentificationSequence[0].ReferencedSegmentNumber) == target_segment_number:
            masks.append(seg.pixel_array[i])
            uids.append(f.DerivationImageSequence[0].SourceImageSequence[0].ReferencedSOPInstanceUID)

    uid_to_seg = {uid: mask for uid, mask in zip(uids, masks)}

    # Calcul des Dice pour chaque slice
    scores = []
    for dicom_file, img_dcm in zip(dicom_files, image_slices):
        sop_uid = img_dcm.SOPInstanceUID
        img_path = os.path.join(images_dir, dicom_file)
        img_array = img_dcm.pixel_array
        h, w = img_array.shape

        # Prédiction modèle
        _, pred_mask = inference_dicom(img_path, ['tumor'], is_CT=True, site='lung')
        pred_mask = np.squeeze(pred_mask)
        pred_mask_resized = cv2.resize(pred_mask, (w, h), interpolation=cv2.INTER_NEAREST)

        # Masque SEG 
        seg_mask = uid_to_seg.get(sop_uid, np.zeros_like(img_array))

        # Dice score
        score = diceScore(pred_mask_resized, seg_mask)
        scores.append(score)

    if scores:
        mean_score = np.mean(scores)
        all_patient_scores.append(mean_score)
        print(f"Patient {patient_id} → Dice moyen : {mean_score:.4f}")
    else:
        print(f"[INFO] Patient {patient_id} → Aucun score calculé (masques absents)")

# --- Boxplot global ---
plt.figure(figsize=(8, 6))
plt.boxplot(all_patient_scores, vert=True, patch_artist=True, labels=["Dice moyen par patient"])
plt.ylabel("Dice Score")
plt.title("Distribution des Dice scores moyens par patient")
plt.grid(True)
plt.show()
Patient 0 → Dice moyen : 0.0521
Patient 1 → Dice moyen : 0.1090
Patient 10 → Dice moyen : 0.6249
Patient 11 → Dice moyen : 0.0245
Patient 14 → Dice moyen : 0.8035
Patient 15 → Dice moyen : 0.0967
Patient 16 → Dice moyen : 0.0308
Patient 17 → Dice moyen : 0.3263
Patient 18 → Dice moyen : 0.0070
Patient 19 → Dice moyen : 0.0719
Patient 2 → Dice moyen : 0.3066
Patient 20 → Dice moyen : 0.7679
Patient 21 → Dice moyen : 0.4781
Patient 22 → Dice moyen : 0.1551
Patient 23 → Dice moyen : 0.6757
Patient 24 → Dice moyen : 0.1868
Patient 25 → Dice moyen : 0.2095
Patient 26 → Dice moyen : 0.0000
Patient 27 → Dice moyen : 0.7414
Patient 29 → Dice moyen : 0.0036
Patient 3 → Dice moyen : 0.5221
Patient 30 → Dice moyen : 0.0508
Patient 31 → Dice moyen : 0.8077
Patient 32 → Dice moyen : 0.0000
Patient 33 → Dice moyen : 0.2361
Patient 34 → Dice moyen : 0.2091
Patient 36 → Dice moyen : 0.4660
Patient 37 → Dice moyen : 0.5169
Patient 38 → Dice moyen : 0.7729
Patient 39 → Dice moyen : 0.1351
Patient 4 → Dice moyen : 0.0521
Patient 40 → Dice moyen : 0.8007
Patient 41 → Dice moyen : 0.0771
Patient 42 → Dice moyen : 0.5355
Patient 43 → Dice moyen : 0.1528
Patient 44 → Dice moyen : 0.1225
Patient 45 → Dice moyen : 0.6166
Patient 46 → Dice moyen : 0.3906
Patient 47 → Dice moyen : 0.0754
Patient 48 → Dice moyen : 0.7342
Patient 49 → Dice moyen : 0.0000
Patient 5 → Dice moyen : 0.0407
Patient 50 → Dice moyen : 0.0374
Patient 51 → Dice moyen : 0.0598
Patient 52 → Dice moyen : 0.7459
Patient 53 → Dice moyen : 0.0440
Patient 54 → Dice moyen : 0.5443
Patient 55 → Dice moyen : 0.0578
Patient 56 → Dice moyen : 0.3502
Patient 6 → Dice moyen : 0.0000
Patient 7 → Dice moyen : 0.6857
Patient 8 → Dice moyen : 0.1068
Patient 9 → Dice moyen : 0.0006
No description has been provided for this image
In [13]:
m = np.mean(all_patient_scores)
print(f"Moyenne : {m}")
Moyenne : 0.29468434365968676

Évaluation qualitative des prédictions par Dice Score (après filtrage)

Ce bloc évalue la qualité des masques prédits par le modèle en les comparant aux masques de référence issus des fichiers de segmentation DICOM-SEG, à l’aide du Dice Score.

Méthodologie :

  • Pour chaque patient, toutes les images DICOM sont traitées par le modèle pour générer un masque de prédiction.
  • Les masques sont ensuite filtrés par cohérence : on utilise un double tri basé sur le score de similarité (auto-Dice) entre masques prédits.
  • Les masques conservés sont comparés à leur équivalent dans la vérité terrain (si disponible) pour calculer un Dice Score moyen par patient.

Résultat :

Les scores moyens sont affichés pour chaque patient, puis visualisés globalement dans un boxplot afin d’évaluer la performance globale du modèle.

Hypothèse :

L’algorithme repose sur l’hypothèse que la majorité des prédictions sont correctes. Le filtrage permet donc d’exclure automatiquement les masques aberrants sans avoir besoin de la vérité terrain.

In [ ]:
import os
import numpy as np
import pydicom
import matplotlib.pyplot as plt
import cv2

def diceScore(mask1, mask2):
    mask1 = (mask1 > 0.5).astype(np.uint8)
    mask2 = (mask2 > 0.5).astype(np.uint8)
    intersection = np.sum(mask1 * mask2)
    total = np.sum(mask1) + np.sum(mask2)
    if total != 0:
        return (2.0 * intersection) / total
    return 0

def dsSort(masks, images, filenames, tr):
    n = len(masks)
    ds = []
    for i, mask1 in enumerate(masks):
        total = 0
        for j, mask2 in enumerate(masks):
            if i != j:
                total += diceScore(mask1, mask2)
        ds.append(total / (n - 1))
    
    # Trier les masques selon leur score moyen
    s = sorted(zip(ds, images, masks, filenames), key=lambda x: x[0])
    cutoff = int(tr * n / 100)
    kept = s[cutoff:]  # On garde les meilleurs
    return list(zip(*kept))  # ds, images, masks, filenames

# Path des patients
base_path = "./test/dcm/"
patients = sorted([p for p in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, p))])

# Résultats Dice moyens finaux
final_patient_scores = []

for patient_id in patients:
    images_dir = os.path.join(base_path, patient_id, "0")
    seg_path = os.path.join(base_path, patient_id, "1", "1-1.dcm")

    if not os.path.exists(seg_path):
        print(f"[WARN] Pas de fichier SEG pour patient {patient_id}")
        continue

    dicom_files = sorted(os.listdir(images_dir))
    image_slices = [pydicom.dcmread(os.path.join(images_dir, f)) for f in dicom_files]

    seg = pydicom.dcmread(seg_path)
    try :
        target_segment_number = get_target_segment_number(seg)
    except Exception as e:
        print(f"Erreur avec le patient {patient_id}: {e}")
        continue

    uids = []
    masks_seg = []
    for i, f in enumerate(seg.PerFrameFunctionalGroupsSequence):
        if int(f.SegmentIdentificationSequence[0].ReferencedSegmentNumber) == target_segment_number:
            masks_seg.append(seg.pixel_array[i])
            uids.append(f.DerivationImageSequence[0].SourceImageSequence[0].ReferencedSOPInstanceUID)

    uid_to_seg = {uid: mask for uid, mask in zip(uids, masks_seg)}

    images = []
    pred_masks = []
    filenames = []
    sop_uids = []

    for dicom_file, img_dcm in zip(dicom_files, image_slices):
        sop_uid = img_dcm.SOPInstanceUID
        img_path = os.path.join(images_dir, dicom_file)
        img_array = img_dcm.pixel_array
        h, w = img_array.shape

        _, pred_mask = inference_dicom(img_path, ['tumor'], is_CT=True, site='lung')
        pred_mask = np.squeeze(pred_mask)
        pred_mask_resized = cv2.resize(pred_mask, (w, h), interpolation=cv2.INTER_NEAREST)

        images.append(img_array)
        pred_masks.append(pred_mask_resized)
        filenames.append(dicom_file)
        sop_uids.append(sop_uid)

    if len(pred_masks) < 3:
        print(f"[INFO] Patient {patient_id} ignoré (trop peu de masques)")
        continue

    # tri par auto-dice (50% les meilleurs)
    it1 = dsSort(pred_masks, images, filenames, tr=50)
    if len(it1[0]) < 2:
        continue
    # retri des meilleurs restants
    ds_final, imgs_final, masks_final, filenames_final = dsSort(it1[2], it1[1], it1[3], tr=50)

    # Calcul du Dice réel )
    scores = []
    for filename, pred_mask in zip(filenames_final, masks_final):
        # Retrouver le SOPInstanceUID correspondant au fichier
        idx = dicom_files.index(filename)
        sop_uid = image_slices[idx].SOPInstanceUID

    if sop_uid in uid_to_seg:
        seg_mask = uid_to_seg[sop_uid]
        dice = diceScore(pred_mask, seg_mask)
        scores.append(dice)

    if scores:
        mean_dice = np.mean(scores)
        final_patient_scores.append(mean_dice)
        print(f"Patient {patient_id} → Dice moyen final : {mean_dice:.4f}")

# --- Boxplot global ---
plt.figure(figsize=(8, 6))
plt.boxplot(final_patient_scores, vert=True, patch_artist=True, labels=["Dice moyens (après filtrage)"])
plt.ylabel("Dice Score")
plt.title("Qualité moyenne des masques filtrés par patient")
plt.grid(True)
plt.show()
Patient 0 → Dice moyen final : 0.3988
Patient 1 → Dice moyen final : 0.4588
Patient 10 → Dice moyen final : 0.8393
Patient 11 → Dice moyen final : 0.0000
Patient 14 → Dice moyen final : 0.9433
Patient 15 → Dice moyen final : 0.0000
Patient 16 → Dice moyen final : 0.0000
Patient 17 → Dice moyen final : 0.8207
Patient 18 → Dice moyen final : 0.0133
Patient 19 → Dice moyen final : 0.0000
Patient 2 → Dice moyen final : 0.1846
Patient 20 → Dice moyen final : 0.8388
Patient 21 → Dice moyen final : 0.7300
Patient 22 → Dice moyen final : 0.6866
Patient 23 → Dice moyen final : 0.8799
Patient 24 → Dice moyen final : 0.5408
Patient 25 → Dice moyen final : 0.8824
Patient 26 → Dice moyen final : 0.0000
Patient 27 → Dice moyen final : 0.7714
Patient 29 → Dice moyen final : 0.0000
Patient 3 → Dice moyen final : 0.7769
Patient 30 → Dice moyen final : 0.3191
Patient 31 → Dice moyen final : 0.9002
Patient 32 → Dice moyen final : 0.0000
Patient 33 → Dice moyen final : 0.8741
Patient 34 → Dice moyen final : 0.7924
Patient 36 → Dice moyen final : 0.9290
Patient 37 → Dice moyen final : 0.7656
Patient 38 → Dice moyen final : 0.9567
Patient 39 → Dice moyen final : 0.9046
Patient 4 → Dice moyen final : 0.3988
Patient 40 → Dice moyen final : 0.9445
Patient 41 → Dice moyen final : 0.0000
Patient 42 → Dice moyen final : 0.9251
Patient 43 → Dice moyen final : 0.0000
Patient 44 → Dice moyen final : 0.0028
Patient 45 → Dice moyen final : 0.9051
Patient 46 → Dice moyen final : 0.8938
Patient 47 → Dice moyen final : 0.0000
Patient 48 → Dice moyen final : 0.8627
Patient 49 → Dice moyen final : 0.0000
Patient 5 → Dice moyen final : 0.0000
Patient 50 → Dice moyen final : 0.1144
Patient 51 → Dice moyen final : 0.4338
Patient 52 → Dice moyen final : 0.8857
Patient 53 → Dice moyen final : 0.0000
Patient 54 → Dice moyen final : 0.7941
Patient 55 → Dice moyen final : 0.0000
Patient 56 → Dice moyen final : 0.9231
Patient 6 → Dice moyen final : 0.0000
Patient 7 → Dice moyen final : 0.9361
Patient 8 → Dice moyen final : 0.1315
Patient 9 → Dice moyen final : 0.0000
No description has been provided for this image
In [6]:
m = np.mean(final_patient_scores)
print(f"Moyenne après filtrage : {m:.4f}")

mauvais = []

for patient, score in zip(patients, final_patient_scores):
    if score < 0.3:
        # print(f"Patient {patient} - score : {score:.4f}")
        mauvais.append(patient)


patientOk = [p for p in patients if p not in mauvais]
patient_score_dict = dict(zip(patients, final_patient_scores))
filtered_scores = [patient_score_dict[p] for p in patientOk]

m_ok = np.mean(filtered_scores)

print(f"Moyenne sur les patients validés : {m_ok:.4f}")

showInference(mauvais[0])
Moyenne après filtrage : 0.4785
Moyenne sur les patients validés : 0.7785
1 → Esophagus
2 → Neoplasm, Primary
3 → Heart
4 → Lung
5 → Lung
6 → Spinal cord
0
No description has been provided for this image
1
No description has been provided for this image
2
No description has been provided for this image
3
No description has been provided for this image
4
No description has been provided for this image
5
No description has been provided for this image
6
No description has been provided for this image
7
No description has been provided for this image
8
No description has been provided for this image
9
No description has been provided for this image
10
No description has been provided for this image
11
No description has been provided for this image
12
No description has been provided for this image
13
No description has been provided for this image
14
No description has been provided for this image
15
No description has been provided for this image
16
No description has been provided for this image
17
No description has been provided for this image
18
No description has been provided for this image
19
No description has been provided for this image
20
No description has been provided for this image
21
No description has been provided for this image
22
No description has been provided for this image
23
No description has been provided for this image
24
No description has been provided for this image
25
No description has been provided for this image
26
No description has been provided for this image
27
No description has been provided for this image
28
No description has been provided for this image

Analyse des résultats

Avant tout filtrage, la moyenne des Dice scores par patient était relativement faible, à 0.29, traduisant une qualité globale limitée des masques. Après application de l’algorithme de tri par cohérence, cette moyenne monte à 0.48, indiquant une amélioration significative grâce à l’élimination automatique des masques peu fiables.

En restreignant l’analyse aux seuls patients pour lesquels le modèle produit des masques cohérents (i.e. score moyen > 0.3), la moyenne atteint 0.78, ce qui reflète une excellente concordance entre prédiction et vérité terrain pour ces cas filtrés.

Application du critère RECIST : Mesurabilité de la lésion

Dans cette section, nous passons de l’évaluation de la qualité des masques de segmentation à l’objectif final du modèle au sein de notre système multi-agent : déterminer automatiquement si une tumeur est mesurable selon le critère RECIST.

Le critère RECIST (Response Evaluation Criteria In Solid Tumors) est une norme utilisée en oncologie pour évaluer la réponse des tumeurs aux traitements. Il impose un seuil minimal de taille — généralement 10 mm — pour qu’une lésion soit considérée comme « mesurable », condition préalable à l’éligibilité du patient à certains essais cliniques.

Le code ci-dessous exploite les fichiers DICOM-SEG (vérité terrain) pour détecter les lésions, extraire leur taille maximale (en mm, via les PixelSpacing), et juger de leur mesurabilité. Ce bloc constitue donc une première étape vers l'automatisation complète de la sélection des patients pour les essais cliniques, basée sur des critères d'imagerie standardisés.

In [7]:
import os
import pydicom
import numpy as np
import cv2
from scipy.spatial.distance import pdist, squareform

def measure_size_native(p1, p2, pixel_spacing):
    dx = pixel_spacing[1]  # col spacing
    dy = pixel_spacing[0]  # row spacing
    delta = np.array([(p1[0] - p2[0]) * dy, (p1[1] - p2[1]) * dx])  # (y, x)
    return np.linalg.norm(delta)

def get_target_segment_number(seg, keyword="neoplasm"):
    for s in seg.SegmentSequence:
        if keyword.lower() in s.SegmentLabel.lower():
            return s.SegmentNumber
    raise ValueError(f"Aucun segment contenant '{keyword}' trouvé dans le fichier de segmentation.")

def recist_from_seg(base_path, seuil_mm=10.0):
    images_dir = os.path.join(base_path, "0")
    seg_path = os.path.join(base_path, "1", "1-1.dcm")

    if not os.path.exists(seg_path):
        raise FileNotFoundError(f"Pas de fichier SEG pour {base_path}")

    seg = pydicom.dcmread(seg_path)
    target_segment_number = get_target_segment_number(seg)

    # Extraire tous les masques du segment d’intérêt
    masks = []
    uids = []
    for i, f in enumerate(seg.PerFrameFunctionalGroupsSequence):
        if int(f.SegmentIdentificationSequence[0].ReferencedSegmentNumber) == target_segment_number:
            mask = seg.pixel_array[i]
            uid = f.DerivationImageSequence[0].SourceImageSequence[0].ReferencedSOPInstanceUID
            if mask.sum() > 0:
                masks.append(mask)
                uids.append(uid)

    uid_to_mask = dict(zip(uids, masks))

    # Lire les DICOM présents dans 0/ (ceux utilisés pour l’analyse)
    dicom_files = sorted(
        [os.path.join(images_dir, f) for f in os.listdir(images_dir)],
        key=lambda x: pydicom.dcmread(x, stop_before_pixels=True).InstanceNumber
    )

    max_lesion = {"size": 0.0, "slice": None}

    for path in dicom_files:
        dcm = pydicom.dcmread(path)
        sop_uid = dcm.SOPInstanceUID
        if sop_uid not in uid_to_mask:
            continue

        mask = uid_to_mask[sop_uid].astype(np.uint8)
        if mask.sum() == 0:
            continue

        pixel_spacing = getattr(dcm, 'PixelSpacing', [1.0, 1.0])
        instance_number = getattr(dcm, 'InstanceNumber', 0)

        contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

        for contour in contours:
            points = contour[:, 0, :]
            if len(points) < 2:
                continue

            i, j = np.unravel_index(np.argmax(squareform(pdist(points))), (len(points), len(points)))
            p1, p2 = points[i], points[j]
            lesion_size_mm = measure_size_native(p1, p2, pixel_spacing)

            if lesion_size_mm > max_lesion["size"]:
                max_lesion = {"size": lesion_size_mm, "slice": instance_number}

    prediction = "Mesurable" if max_lesion["size"] > seuil_mm else "Non mesurable"

    return max_lesion["size"], prediction


# Chemin vers un dossier patient (structure : ./test/dcm/{patient_id}/0 et /1)
patient_id = "45"
base_path = f"./test/dcm/{patient_id}"

# Appel de la fonction RECIST
size_mm, prediction = recist_from_seg(base_path)

# Affichage du résultat
print(f"📌 Patient {patient_id}")
print(f"   ➤ Taille max de lésion : {size_mm:.2f} mm")
print(f"   ➤ Statut RECIST : {prediction}")
📌 Patient 45
   ➤ Taille max de lésion : 67.13 mm
   ➤ Statut RECIST : Mesurable

Évaluation RECIST sur les prédictions du modèle

Après avoir mesuré la taille des lésions à partir des masques de vérité terrain (DICOM-SEG), on applique à présent la même logique sur les masques issus de l’inférence.

Cette fonction applique le modèle sur chaque coupe DICOM du patient, nettoie et binarise le masque prédit, puis recherche la lésion la plus longue à partir des contours détectés. Elle permet ainsi d'estimer la mesurabilité d'une tumeur uniquement à partir des prédictions, sans utiliser les données de vérité terrain.

Elle constitue un point de comparaison direct avec les résultats issus des fichiers SEG, dans le but de valider le modèle pour une utilisation autonome dans un contexte clinique.

Juste après on réalise la même mais après filtrage des masques.

In [8]:
def clean_mask(mask):
    """
    Nettoie un masque : squeeze, nan → 0, binarise à 0.5, cast en uint8, compatible OpenCV.
    """
    if mask is None:
        return None

    # 🔁 Squeeze toutes les dimensions de taille 1 (ex: (1, H, W) → (H, W))
    mask = np.squeeze(mask)

    # ✅ Vérification 2D après squeeze
    if mask.ndim != 2:
        raise ValueError(f"Masque invalide, shape={mask.shape}")

    # 🔁 Remplacer NaN, inf, -inf par 0
    mask = np.nan_to_num(mask, nan=0.0, posinf=0.0, neginf=0.0)

    # 🔁 Binarisation
    mask = (mask > 0.5).astype(np.uint8)

    # 🔁 Mémoire contiguë (OpenCV)
    return np.ascontiguousarray(mask)


def measure_size_native(p1, p2, pixel_spacing):
    dx = pixel_spacing[1]
    dy = pixel_spacing[0]
    delta = np.array([(p1[0] - p2[0]) * dy, (p1[1] - p2[1]) * dx])
    return np.linalg.norm(delta)

def recist_from_prediction_strict(patient_id, seuil_mm=10.0, site="lung"):
    images_dir = f"./test/dcm/{patient_id}/0/"
    if not os.path.exists(images_dir):
        raise FileNotFoundError(f"Dossier du patient {patient_id} introuvable.")

    dicom_files = sorted(
        [os.path.join(images_dir, f) for f in os.listdir(images_dir)],
        key=lambda x: pydicom.dcmread(x, stop_before_pixels=True).InstanceNumber
    )

    max_lesion = {"size": 0.0, "slice": None, "index": None}

    for idx, path in enumerate(dicom_files):
        dcm = pydicom.dcmread(path)
        pixel_spacing = getattr(dcm, 'PixelSpacing', [1.0, 1.0])
        instance_number = getattr(dcm, 'InstanceNumber', 0)

        # Inférence modèle
        _, pred_mask_prob = inference_dicom(path, ['tumor'], is_CT=True, site=site)

        try:
            mask = clean_mask(pred_mask_prob)
        except Exception as e:
            print(f"❌ Masque invalide pour {path} : {e}")
            continue

        if mask.sum() == 0:
            continue

        try:
            contours, _ = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        except Exception as e:
            print(f"⚠️ Erreur contours sur {path} : {e}")
            continue

        for contour in contours:
            if len(contour) < 2:
                continue
            points = contour[:, 0, :]
            i1, i2 = np.unravel_index(np.argmax(squareform(pdist(points))), (len(points), len(points)))
            p1, p2 = points[i1], points[i2]
            size_mm = measure_size_native(p1, p2, pixel_spacing)

            if size_mm > max_lesion["size"]:
                max_lesion = {"size": size_mm, "slice": instance_number, "index": idx}

    prediction = "Mesurable" if max_lesion["size"] > seuil_mm else "Non mesurable"

    # 📢 Affichage du résultat avec rang
    # print(f"📌 Patient {patient_id} – Lésion max : {max_lesion['size']:.2f} mm – {prediction}")
    # if max_lesion["index"] is not None:
    #     print(f"   ➤ Rang dans dossier 0 : {max_lesion['index']} (InstanceNumber : {max_lesion['slice']})")

    return max_lesion["size"], prediction

size, status = recist_from_prediction_strict(45)
print()
# showInference(45)

In [9]:
def recist_from_prediction_filtered(patient_id, seuil_mm=10.0, site="lung"):
    images_dir = f"./test/dcm/{patient_id}/0/"
    if not os.path.exists(images_dir):
        raise FileNotFoundError(f"Dossier du patient {patient_id} introuvable.")

    dicom_files = sorted(
        [os.path.join(images_dir, f) for f in os.listdir(images_dir)],
        key=lambda x: pydicom.dcmread(x, stop_before_pixels=True).InstanceNumber
    )

    pred_masks = []
    images = []
    filenames = []

    for path in dicom_files:
        dcm = pydicom.dcmread(path)
        img_array = dcm.pixel_array
        try:
            _, pred_mask_prob = inference_dicom(path, ['tumor'], is_CT=True, site=site)
            mask = clean_mask(pred_mask_prob)
        except Exception as e:
            print(f"❌ Masque invalide pour {path} : {e}")
            continue

        if mask.sum() == 0:
            continue

        pred_masks.append(mask)
        images.append(img_array)
        filenames.append(os.path.basename(path))

    if len(pred_masks) < 3:
        print(f"[INFO] Patient {patient_id} ignoré (trop peu de masques valides)")
        return 0.0, "Non mesurable"

    # Étape 1 : tri par auto-dice (50% les meilleurs)
    it1 = dsSort(pred_masks, images, filenames, tr=50)

    if not it1 or len(it1[0]) == 0:
        print(f"[INFO] Patient {patient_id} ignoré (aucun masque après tri)")
        return 0.0, "Non mesurable"

    # Étape 2 : retri seulement si ≥ 2 masques restants
    if len(it1[0]) >= 2:
        ds_final, imgs_final, masks_final, filenames_final = dsSort(it1[2], it1[1], it1[3], tr=50)
    else:
        ds_final, imgs_final, masks_final, filenames_final = it1


    if not masks_final:
        print(f"[INFO] Patient {patient_id} ignoré (tri 2 vide)")
        return 0.0, "Non mesurable"

    max_lesion = {"size": 0.0, "slice": None, "index": None}

    for i in range(len(masks_final)):
        mask = masks_final[i]
        img = imgs_final[i]
        filename = filenames_final[i]
        dcm_path = os.path.join(images_dir, filename)
        dcm = pydicom.dcmread(dcm_path)
        pixel_spacing = getattr(dcm, 'PixelSpacing', [1.0, 1.0])
        instance_number = getattr(dcm, 'InstanceNumber', 0)

        try:
            contours, _ = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        except Exception as e:
            print(f"⚠️ Erreur contours sur {filename} : {e}")
            continue

        for contour in contours:
            if len(contour) < 2:
                continue
            points = contour[:, 0, :]
            i1, i2 = np.unravel_index(np.argmax(squareform(pdist(points))), (len(points), len(points)))
            p1, p2 = points[i1], points[i2]
            size_mm = measure_size_native(p1, p2, pixel_spacing)

            if size_mm > max_lesion["size"]:
                max_lesion = {
                    "size": size_mm,
                    "slice": instance_number,
                    "index": dicom_files.index(os.path.join(images_dir, filename))
                }

    prediction = "Mesurable" if max_lesion["size"] > seuil_mm else "Non mesurable"

    # 📢 Affichage
    # print(f"📌 Patient {patient_id} – Lésion max : {max_lesion['size']:.2f} mm – {prediction}")
    # if max_lesion["index"] is not None:
    #     print(f"   ➤ Rang dans dossier 0 : {max_lesion['index']} (InstanceNumber : {max_lesion['slice']})")

    return max_lesion["size"], prediction

recist_from_prediction_filtered(45)
Out[9]:
(115.21145895265799, 'Mesurable')

Comparaison globale des tailles de lésion : vérité terrain vs prédiction

On termine cette évaluation en comparant directement, pour chaque patient, la taille maximale de lésion extraite depuis les fichiers de segmentation DICOM-SEG (vérité terrain) avec celle issue des masques prédits par le modèle.

Pour chaque cas, la différence (en mm) entre les deux mesures est calculée, ainsi que le statut mesurable / non mesurable selon le critère RECIST. Le modèle est ainsi évalué selon deux axes :

  • L’écart moyen absolu entre la taille prédite et la taille de référence.
  • La précision RECIST : pourcentage de patients pour lesquels le modèle prédit correctement si la tumeur est mesurable ou non.

Les résultats sont ensuite résumés sous forme de statistiques globales et d’un boxplot illustrant la distribution des erreurs de mesure.

In [45]:
import numpy as np
import matplotlib.pyplot as plt

def eval_recist_all(base_path="./test/dcm/", seuil_mm=10.0):
    patients = sorted([p for p in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, p))])
    
    abs_diffs = []
    raw_diffs = []
    n_correct_pred = 0
    n_valid = 0

    for patient_id in patients:
        patient_path = os.path.join(base_path, patient_id)
        try:
            gt_size, gt_status = recist_from_seg(patient_path, seuil_mm=seuil_mm)
        except Exception as e:
            print(f"[SKIP] Patient {patient_id} – erreur GT : {e}")
            continue

        try:
            pred_size, pred_status = recist_from_prediction_filtered(patient_id, seuil_mm=seuil_mm)
        except Exception as e:
            print(f"[SKIP] Patient {patient_id} – erreur prédiction : {e}")
            continue

        diff = pred_size - gt_size
        raw_diffs.append(diff)
        abs_diffs.append(abs(diff))
        n_valid += 1

        if gt_status == pred_status:
            n_correct_pred += 1

        print(f"- Patient {patient_id} → GT = {gt_size:.2f} mm | Pred = {pred_size:.2f} mm | Δ = {diff:.2f} mm | Corr = {gt_status == pred_status}")

    if n_valid == 0:
        print("Aucun patient valide pour l’évaluation.")
        return 0.0, 0.0

    # Statistiques
    abs_diffs = np.array(abs_diffs)
    raw_diffs = np.array(raw_diffs)

    mean_abs_diff = abs_diffs.mean()
    mean_diff = raw_diffs.mean()
    std_diff = raw_diffs.std()
    accuracy = n_correct_pred / n_valid * 100

    # Affichage final
    print("\n\n\n ================================= Résultats globaux ================================= \n")
    print(f"  ➤ Écart absolu moyen : {mean_abs_diff:.2f} mm")
    print(f"  ➤ Écart moyen : {mean_diff:.2f} mm")
    print(f"  ➤ Écart-type : {std_diff:.2f} mm")
    print(f"  ➤ Précision RECIST : {accuracy:.2f}% ({n_correct_pred}/{n_valid})")

    # Box plot
    plt.figure(figsize=(8, 5))
    plt.boxplot(abs_diffs, vert=True, patch_artist=True, labels=["Écarts absolus"])
    plt.ylabel("Écart |GT - Pred| (mm)")
    plt.title("Distribution des écarts absolus (taille de lésion)")
    plt.grid(True)
    plt.show()

    return mean_abs_diff, mean_diff, std_diff, accuracy

mean_abs, mean_signed, std, acc = eval_recist_all()
- Patient 0 → GT = 80.97 mm | Pred = 130.47 mm | Δ = 49.50 mm | Corr = True
- Patient 1 → GT = 102.73 mm | Pred = 171.89 mm | Δ = 69.15 mm | Corr = True
- Patient 10 → GT = 82.54 mm | Pred = 148.62 mm | Δ = 66.09 mm | Corr = True
- Patient 11 → GT = 93.92 mm | Pred = 86.77 mm | Δ = -7.15 mm | Corr = True
- Patient 14 → GT = 23.35 mm | Pred = 45.79 mm | Δ = 22.44 mm | Corr = True
- Patient 15 → GT = 63.50 mm | Pred = 213.89 mm | Δ = 150.39 mm | Corr = True
- Patient 16 → GT = 103.00 mm | Pred = 224.42 mm | Δ = 121.42 mm | Corr = True
- Patient 17 → GT = 72.94 mm | Pred = 138.35 mm | Δ = 65.41 mm | Corr = True
- Patient 18 → GT = 126.74 mm | Pred = 22.97 mm | Δ = -103.77 mm | Corr = True
- Patient 19 → GT = 72.83 mm | Pred = 85.31 mm | Δ = 12.48 mm | Corr = True
- Patient 2 → GT = 61.68 mm | Pred = 132.71 mm | Δ = 71.02 mm | Corr = True
- Patient 20 → GT = 23.64 mm | Pred = 45.91 mm | Δ = 22.27 mm | Corr = True
- Patient 21 → GT = 70.83 mm | Pred = 114.16 mm | Δ = 43.33 mm | Corr = True
- Patient 22 → GT = 131.75 mm | Pred = 186.02 mm | Δ = 54.27 mm | Corr = True
- Patient 23 → GT = 37.45 mm | Pred = 60.24 mm | Δ = 22.80 mm | Corr = True
- Patient 24 → GT = 82.70 mm | Pred = 54.62 mm | Δ = -28.08 mm | Corr = True
- Patient 25 → GT = 98.68 mm | Pred = 128.08 mm | Δ = 29.40 mm | Corr = True
[INFO] Patient 26 ignoré (trop peu de masques valides)
- Patient 26 → GT = 17.31 mm | Pred = 0.00 mm | Δ = -17.31 mm | Corr = False
- Patient 27 → GT = 59.33 mm | Pred = 97.94 mm | Δ = 38.61 mm | Corr = True
- Patient 29 → GT = 63.05 mm | Pred = 56.17 mm | Δ = -6.89 mm | Corr = True
- Patient 3 → GT = 65.01 mm | Pred = 121.13 mm | Δ = 56.12 mm | Corr = True
- Patient 30 → GT = 77.04 mm | Pred = 75.17 mm | Δ = -1.87 mm | Corr = True
- Patient 31 → GT = 30.88 mm | Pred = 53.82 mm | Δ = 22.94 mm | Corr = True
- Patient 32 → GT = 102.38 mm | Pred = 143.65 mm | Δ = 41.27 mm | Corr = True
- Patient 33 → GT = 65.08 mm | Pred = 120.39 mm | Δ = 55.31 mm | Corr = True
- Patient 34 → GT = 92.38 mm | Pred = 158.87 mm | Δ = 66.49 mm | Corr = True
- Patient 36 → GT = 27.99 mm | Pred = 42.95 mm | Δ = 14.96 mm | Corr = True
- Patient 37 → GT = 73.45 mm | Pred = 124.28 mm | Δ = 50.82 mm | Corr = True
- Patient 38 → GT = 25.65 mm | Pred = 48.59 mm | Δ = 22.94 mm | Corr = True
- Patient 39 → GT = 100.26 mm | Pred = 159.00 mm | Δ = 58.74 mm | Corr = True
- Patient 4 → GT = 80.97 mm | Pred = 130.47 mm | Δ = 49.50 mm | Corr = True
- Patient 40 → GT = 61.19 mm | Pred = 123.38 mm | Δ = 62.20 mm | Corr = True
- Patient 41 → GT = 74.37 mm | Pred = 34.31 mm | Δ = -40.06 mm | Corr = True
- Patient 42 → GT = 88.97 mm | Pred = 137.84 mm | Δ = 48.87 mm | Corr = True
- Patient 43 → GT = 78.56 mm | Pred = 141.45 mm | Δ = 62.89 mm | Corr = True
- Patient 44 → GT = 72.75 mm | Pred = 201.79 mm | Δ = 129.04 mm | Corr = True
- Patient 45 → GT = 67.13 mm | Pred = 115.21 mm | Δ = 48.08 mm | Corr = True
- Patient 46 → GT = 48.78 mm | Pred = 65.87 mm | Δ = 17.09 mm | Corr = True
- Patient 47 → GT = 40.26 mm | Pred = 231.51 mm | Δ = 191.24 mm | Corr = True
- Patient 48 → GT = 29.05 mm | Pred = 53.45 mm | Δ = 24.40 mm | Corr = True
- Patient 49 → GT = 45.54 mm | Pred = 88.35 mm | Δ = 42.80 mm | Corr = True
- Patient 5 → GT = 69.21 mm | Pred = 99.91 mm | Δ = 30.70 mm | Corr = True
- Patient 50 → GT = 91.95 mm | Pred = 132.87 mm | Δ = 40.92 mm | Corr = True
- Patient 51 → GT = 99.92 mm | Pred = 87.46 mm | Δ = -12.46 mm | Corr = True
- Patient 52 → GT = 26.67 mm | Pred = 51.56 mm | Δ = 24.89 mm | Corr = True
- Patient 53 → GT = 41.22 mm | Pred = 36.31 mm | Δ = -4.91 mm | Corr = True
- Patient 54 → GT = 26.44 mm | Pred = 45.94 mm | Δ = 19.50 mm | Corr = True
- Patient 55 → GT = 76.97 mm | Pred = 176.09 mm | Δ = 99.12 mm | Corr = True
- Patient 56 → GT = 76.97 mm | Pred = 125.87 mm | Δ = 48.90 mm | Corr = True
- Patient 6 → GT = 60.96 mm | Pred = 56.54 mm | Δ = -4.42 mm | Corr = True
- Patient 7 → GT = 52.84 mm | Pred = 104.08 mm | Δ = 51.24 mm | Corr = True
- Patient 8 → GT = 88.91 mm | Pred = 108.31 mm | Δ = 19.39 mm | Corr = True
- Patient 9 → GT = 47.35 mm | Pred = 40.80 mm | Δ = -6.55 mm | Corr = True



 ================================= Résultats globaux ================================= 

  ➤ Écart absolu moyen : 46.65 mm
  ➤ Écart moyen : 37.84 mm
  ➤ Écart-type : 46.47 mm
  ➤ Précision RECIST : 98.11% (52/53)
No description has been provided for this image
In [ ]:
base_path="./test/dcm/"
patients = sorted([p for p in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, p))])

tot = 0
mesurable = 0

for patient_id in patients:
    _ , prediction = recist_from_seg(os.path.join(base_path, patient_id))
    tot += 1
    if prediction == "Mesurable" :.
        mesurable += 1
    
print(str(mesurable/tot * 100)+ " % de lésions mesurables")
100.0 % de lésions mesurables

Conclusion et limites de l’évaluation

Ce travail avait pour objectif d’évaluer la capacité du modèle BiomedParse à segmenter automatiquement les lésions pulmonaires visibles sur des images CT (format DICOM), et à en extraire des mesures cliniquement exploitables via le critère RECIST. Ce critère est couramment utilisé pour déterminer si une tumeur est mesurable ou non, ce qui conditionne souvent l’éligibilité d’un patient aux essais cliniques.

L’objectif principal est d’estimer automatiquement la taille maximale des lésions tumorales à partir des masques prédits par le modèle, afin de déterminer leur caractère mesurable selon les critères RECIST. Pour évaluer cette méthode, les résultats ont été comparés à ceux obtenus à partir des masques de vérité terrain (issus des fichiers DICOM-SEG). Bien que la précision binaire (mesurable / non mesurable) soit élevée (98.11% de concordance), les écarts sur la mesure des tailles entre prédictions et ground-truth restent particulièrement importants :

  • Écart absolu moyen : 46.65 mm
  • Écart moyen (signé) : +37.84 mm
  • Écart-type : 46.47 mm

Ces écarts traduisent une forte surestimation de la taille des lésions par le modèle. Autrement dit, le modèle détecte généralement bien qu’il y a une lésion, mais il tend à la surdimensionner fortement, ce qui limite la fiabilité des mesures.

Par ailleurs, le fait que 100 % des patients de la base soient considérés “mesurables” (d’après les masques ground-truth) révèle une limite structurelle de la base elle-même. En pratique, les bases DICOM-SEG accessibles contiennent majoritairement des cas annotés précisément — donc typiquement des lésions bien visibles et mesurables. Il est donc difficile d’évaluer le modèle sur des cas limites ou non mesurables, faute de données appropriées.

En conclusion, si ce notebook constitue un bon point de départ pour une chaîne d’analyse RECIST automatisée, il met aussi en lumière plusieurs limitations majeures :

  • La difficulté d’obtenir une estimation fiable de la taille réelle de la tumeur à partir des prédictions.
  • Le besoin de bases de données plus diversifiées (incluant des lésions non mesurables).
  • La nécessité d’un contrôle qualité plus fin des masques, ou d’une validation médicale en aval.
In [14]:
import shutil
import json
import os
import pydicom
import cv2
import numpy as np
from scipy.spatial.distance import pdist, squareform

def generate_non_measurable_cases(base_path="./test/dcm/", seuil_mm=10.0):
    existing_patients = sorted([int(p) for p in os.listdir(base_path) if p.isdigit()])
    next_id = max(existing_patients) + 1 if existing_patients else 1000

    for patient_id in existing_patients:
        patient_path = os.path.join(base_path, str(patient_id))
        images_dir = os.path.join(patient_path, "0")
        seg_path = os.path.join(patient_path, "1", "1-1.dcm")

        if not os.path.exists(images_dir) or not os.path.exists(seg_path):
            continue

        try:
            seg = pydicom.dcmread(seg_path)
            target_segment_number = get_target_segment_number(seg)
        except Exception as e:
            print(f"[SKIP] Patient {patient_id} : erreur SEG : {e}")
            continue

        # Masques utiles
        uids = []
        masks = []
        for i, f in enumerate(seg.PerFrameFunctionalGroupsSequence):
            if int(f.SegmentIdentificationSequence[0].ReferencedSegmentNumber) == target_segment_number:
                mask = seg.pixel_array[i]
                uid = f.DerivationImageSequence[0].SourceImageSequence[0].ReferencedSOPInstanceUID
                if mask.sum() > 0:
                    uids.append(uid)
                    masks.append(mask)
        uid_to_mask = dict(zip(uids, masks))

        dicom_files = sorted(
            [f for f in os.listdir(images_dir)],
            key=lambda x: pydicom.dcmread(os.path.join(images_dir, x), stop_before_pixels=True).InstanceNumber
        )

        non_measurable_paths = []

        for f in dicom_files:
            dcm_path = os.path.join(images_dir, f)
            dcm = pydicom.dcmread(dcm_path)
            uid = dcm.SOPInstanceUID

            if uid not in uid_to_mask:
                continue

            mask = uid_to_mask[uid]
            pixel_spacing = getattr(dcm, 'PixelSpacing', [1.0, 1.0])

            contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
            for contour in contours:
                if len(contour) < 2:
                    continue
                points = contour[:, 0, :]
                i1, i2 = np.unravel_index(np.argmax(squareform(pdist(points))), (len(points), len(points)))
                p1, p2 = points[i1], points[i2]
                size_mm = measure_size_native(p1, p2, pixel_spacing)
                if size_mm < seuil_mm:
                    non_measurable_paths.append(dcm_path)
                    break  # une lésion non mesurable suffit pour garder la coupe

        # Ajout si ≥1 coupe détectée
        if non_measurable_paths:
            new_patient_id = str(next_id)
            new_path = os.path.join(base_path, new_patient_id)
            os.makedirs(os.path.join(new_path, "0"), exist_ok=True)
            os.makedirs(os.path.join(new_path, "1"), exist_ok=True)

            for dcm_path in non_measurable_paths:
                shutil.copy2(dcm_path, os.path.join(new_path, "0", os.path.basename(dcm_path)))

            shutil.copy2(seg_path, os.path.join(new_path, "1", "1-1.dcm"))
            print(f"[NEW] {len(non_measurable_paths)} coupes non mesurables → patient {new_patient_id}")
            next_id += 1

generate_non_measurable_cases()
[NEW] 1 coupes non mesurables → patient 57
[NEW] 1 coupes non mesurables → patient 58
[NEW] 1 coupes non mesurables → patient 59
[NEW] 2 coupes non mesurables → patient 60
[NEW] 1 coupes non mesurables → patient 61
[NEW] 1 coupes non mesurables → patient 62
[NEW] 1 coupes non mesurables → patient 63
[NEW] 1 coupes non mesurables → patient 64
[NEW] 1 coupes non mesurables → patient 65
[NEW] 1 coupes non mesurables → patient 66
In [16]:
base_path = "./test/dcm/"
patients = sorted([p for p in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, p))])

tot = 0
mesurable = 0

for patient_id in patients:
    try:
        _, prediction = recist_from_seg(os.path.join(base_path, patient_id))
        tot += 1
        if prediction == "Mesurable":
            mesurable += 1
    except Exception as e:
        print(f"[SKIP] {patient_id}: {e}")

print(f"{mesurable/tot * 100:.2f}% de lésions mesurables")
87.30% de lésions mesurables
In [18]:
import numpy as np
import matplotlib.pyplot as plt

def eval_recist_all(base_path="./test/dcm/", seuil_mm=10.0):
    patients = sorted([p for p in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, p))])
    
    abs_diffs = []
    raw_diffs = []
    n_correct_pred = 0
    n_valid = 0

    for patient_id in patients:
        patient_path = os.path.join(base_path, patient_id)
        try:
            gt_size, gt_status = recist_from_seg(patient_path, seuil_mm=seuil_mm)
        except Exception as e:
            print(f"[SKIP] Patient {patient_id} – erreur GT : {e}")
            continue
        
        if len(os.listdir(os.path.join(base_path, patient_id, "0"))) > 1 :
            try:
                pred_size, pred_status = recist_from_prediction_filtered(patient_id, seuil_mm=seuil_mm)
            except Exception as e:
                print(f"[SKIP] Patient {patient_id} – erreur prédiction : {e}")
                continue
        else :
            pred_size, pred_status = recist_from_prediction_strict(patient_id)

        diff = pred_size - gt_size
        raw_diffs.append(diff)
        abs_diffs.append(abs(diff))
        n_valid += 1

        if gt_status == pred_status:
            n_correct_pred += 1

        print(f"- Patient {patient_id} → GT = {gt_size:.2f} mm | Pred = {pred_size:.2f} mm | Δ = {diff:.2f} mm | Corr = {gt_status == pred_status}")

    if n_valid == 0:
        print("Aucun patient valide pour l’évaluation.")
        return 0.0, 0.0

    # Statistiques
    abs_diffs = np.array(abs_diffs)
    raw_diffs = np.array(raw_diffs)

    mean_abs_diff = abs_diffs.mean()
    mean_diff = raw_diffs.mean()
    std_diff = raw_diffs.std()
    accuracy = n_correct_pred / n_valid * 100

    # Affichage final
    print("\n\n\n ================================= Résultats globaux ================================= \n")
    print(f"  ➤ Écart absolu moyen : {mean_abs_diff:.2f} mm")
    print(f"  ➤ Écart moyen : {mean_diff:.2f} mm")
    print(f"  ➤ Écart-type : {std_diff:.2f} mm")
    print(f"  ➤ Précision RECIST : {accuracy:.2f}% ({n_correct_pred}/{n_valid})")

    # Box plot
    plt.figure(figsize=(8, 5))
    plt.boxplot(abs_diffs, vert=True, patch_artist=True, labels=["Écarts absolus"])
    plt.ylabel("Écart |GT - Pred| (mm)")
    plt.title("Distribution des écarts absolus (taille de lésion)")
    plt.grid(True)
    plt.show()

    return mean_abs_diff, mean_diff, std_diff, accuracy

mean_abs, mean_signed, std, acc = eval_recist_all()
- Patient 0 → GT = 80.97 mm | Pred = 130.47 mm | Δ = 49.50 mm | Corr = True
- Patient 1 → GT = 102.73 mm | Pred = 171.89 mm | Δ = 69.15 mm | Corr = True
- Patient 10 → GT = 82.54 mm | Pred = 148.62 mm | Δ = 66.09 mm | Corr = True
- Patient 11 → GT = 93.92 mm | Pred = 86.77 mm | Δ = -7.15 mm | Corr = True
- Patient 14 → GT = 23.35 mm | Pred = 45.79 mm | Δ = 22.44 mm | Corr = True
- Patient 15 → GT = 63.50 mm | Pred = 213.89 mm | Δ = 150.39 mm | Corr = True
- Patient 16 → GT = 103.00 mm | Pred = 224.42 mm | Δ = 121.42 mm | Corr = True
- Patient 17 → GT = 72.94 mm | Pred = 138.35 mm | Δ = 65.41 mm | Corr = True
- Patient 18 → GT = 126.74 mm | Pred = 22.97 mm | Δ = -103.77 mm | Corr = True
- Patient 19 → GT = 72.83 mm | Pred = 85.31 mm | Δ = 12.48 mm | Corr = True
- Patient 2 → GT = 61.68 mm | Pred = 132.71 mm | Δ = 71.02 mm | Corr = True
- Patient 20 → GT = 23.64 mm | Pred = 45.91 mm | Δ = 22.27 mm | Corr = True
- Patient 21 → GT = 70.83 mm | Pred = 114.16 mm | Δ = 43.33 mm | Corr = True
- Patient 22 → GT = 131.75 mm | Pred = 186.02 mm | Δ = 54.27 mm | Corr = True
- Patient 23 → GT = 37.45 mm | Pred = 60.24 mm | Δ = 22.80 mm | Corr = True
- Patient 24 → GT = 82.70 mm | Pred = 54.62 mm | Δ = -28.08 mm | Corr = True
- Patient 25 → GT = 98.68 mm | Pred = 128.08 mm | Δ = 29.40 mm | Corr = True
[INFO] Patient 26 ignoré (trop peu de masques valides)
- Patient 26 → GT = 17.31 mm | Pred = 0.00 mm | Δ = -17.31 mm | Corr = False
- Patient 27 → GT = 59.33 mm | Pred = 97.94 mm | Δ = 38.61 mm | Corr = True
- Patient 29 → GT = 63.05 mm | Pred = 56.17 mm | Δ = -6.89 mm | Corr = True
- Patient 3 → GT = 65.01 mm | Pred = 121.13 mm | Δ = 56.12 mm | Corr = True
- Patient 30 → GT = 77.04 mm | Pred = 75.17 mm | Δ = -1.87 mm | Corr = True
- Patient 31 → GT = 30.88 mm | Pred = 53.82 mm | Δ = 22.94 mm | Corr = True
- Patient 32 → GT = 102.38 mm | Pred = 143.65 mm | Δ = 41.27 mm | Corr = True
- Patient 33 → GT = 65.08 mm | Pred = 120.39 mm | Δ = 55.31 mm | Corr = True
- Patient 34 → GT = 92.38 mm | Pred = 158.87 mm | Δ = 66.49 mm | Corr = True
- Patient 36 → GT = 27.99 mm | Pred = 42.95 mm | Δ = 14.96 mm | Corr = True
- Patient 37 → GT = 73.45 mm | Pred = 124.28 mm | Δ = 50.82 mm | Corr = True
- Patient 38 → GT = 25.65 mm | Pred = 48.59 mm | Δ = 22.94 mm | Corr = True
- Patient 39 → GT = 100.26 mm | Pred = 159.00 mm | Δ = 58.74 mm | Corr = True
- Patient 4 → GT = 80.97 mm | Pred = 130.47 mm | Δ = 49.50 mm | Corr = True
- Patient 40 → GT = 61.19 mm | Pred = 123.38 mm | Δ = 62.20 mm | Corr = True
- Patient 41 → GT = 74.37 mm | Pred = 34.31 mm | Δ = -40.06 mm | Corr = True
- Patient 42 → GT = 88.97 mm | Pred = 137.84 mm | Δ = 48.87 mm | Corr = True
- Patient 43 → GT = 78.56 mm | Pred = 141.45 mm | Δ = 62.89 mm | Corr = True
- Patient 44 → GT = 72.75 mm | Pred = 201.79 mm | Δ = 129.04 mm | Corr = True
- Patient 45 → GT = 67.13 mm | Pred = 115.21 mm | Δ = 48.08 mm | Corr = True
- Patient 46 → GT = 48.78 mm | Pred = 65.87 mm | Δ = 17.09 mm | Corr = True
- Patient 47 → GT = 40.26 
... [truncated for display only] ...
No description has been provided for this image

Avant l'ajout de cas non mesurables :
➤ Écart absolu moyen : 46.65 mm
➤ Écart moyen : 37.84 mm
➤ Écart-type : 46.47 mm
➤ Précision RECIST : 98.11% (52/53)

Avec cas non mesurables :
➤ Écart absolu moyen : 43.86 mm
➤ Écart moyen : 35.82 mm
➤ Écart-type : 44.09 mm
➤ Précision RECIST : 84.13% (53/63)

Les cas non-mesurables sont clairement mal traités.